SPB Git forge
3commits 1branches 0releases
417.0 KBsize
maindefault branch
10 days agolast push
TypeScript 66.5% Python 30.9% JavaScript 1.4% CSS 0.7%
13.2 KB · 246 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { Bars, Donut, HBars, StackedBars } from '@/components/charts/charts';5import { Block, EventsList, HeroFacts, KpiStrip, PlannedUnavailable, ScrollTable, Tag, entityMetadata } from '@/components/entities/shared';6import { ConstellationsMiniTable, LaunchesTable, OperatorsMiniTable, SatellitesTable } from '@/components/entities/tables';7import { WorldMap } from '@/components/map/world-map';8import { Container } from '@/components/ui/section';9import { Unavailable } from '@/components/ui/unavailable';10import { ApiError, api, safe } from '@/lib/api';11import { fmtDate, fmtDateTime, fmtInt, num, titleCase } from '@/lib/format';12import { MISSION_LABELS, OBJECT_TYPE_LABELS, ORBIT_CLASS_COLORS, SITE_URL, routes } from '@/lib/site';13import type { CountryDetail } from '@/lib/types';1415type Props = { params: Promise<{ slug: string }> };1617async function load(slug: string): Promise<{ d: CountryDetail; generatedAt: string } | null> {18  try {19    const res = await api.country(slug);20    return { d: res.data, generatedAt: res.meta.generated_at };21  } catch (e) {22    if (e instanceof ApiError && e.notFound) return null;23    throw e;24  }25}2627export async function generateMetadata({ params }: Props): Promise<Metadata> {28  const { slug } = await params;29  const r = await load(slug).catch(() => null);30  if (!r) return { title: 'Country not found', robots: { index: false } };31  const { d } = r;32  return entityMetadata({33    title: `${d.name} in orbit — ${fmtInt(d.active_payloads)} active satellites, ${fmtInt(d.objects_on_orbit)} objects`,34    description: `${d.name}: ${fmtInt(d.active_payloads)} active payloads, ${fmtInt(d.objects_on_orbit)} objects on orbit including ${fmtInt(d.debris_on_orbit)} debris fragments, ${fmtInt(d.launches)} launches and ${fmtInt(d.operators)} operators. Rankings, orbital and mission distribution, growth, launch sites and recent launches.`,35    path: routes.country(d.slug),36  });37}3839function Rank({ n, of }: { n: unknown; of: number | null }) {40  const v = num(n as never);41  if (v === null) return <>—</>;42  return (43    <>44      #{v}45      {of !== null && <span className="text-ink-3"> of {of}</span>}46    </>47  );48}4950export default async function CountryPage({ params }: Props) {51  const { slug } = await params;52  const [r, all] = await Promise.all([load(slug), safe(api.countries())]);53  if (!r) notFound();54  const { d, generatedAt } = r;55  const nCountries = all?.data.length ?? null;5657  const orbitData = d.orbit_distribution.map((o) => ({ label: o.orbit_class, value: num(o.count) ?? 0, color: ORBIT_CLASS_COLORS[o.orbit_class] ?? 'var(--other)' })).filter((x) => x.value > 0).sort((a, b) => b.value - a.value);58  const missionData = d.mission_distribution.map((m) => ({ label: MISSION_LABELS[m.mission_type] ?? titleCase(m.mission_type), value: num(m.count) ?? 0 })).filter((x) => x.value > 0).sort((a, b) => b.value - a.value);59  const payloadGrowth = d.growth.map((g) => {60    const p = num(g.payloads) ?? 0;61    const still = Math.min(p, num(g.still_active) ?? 0);62    return { x: g.year, still_active: still, retired: Math.max(0, p - still) };63  });64  const launchGrowth = d.growth.map((g) => ({ x: g.year, y: num(g.launches) ?? 0 }));65  const sites = d.launch_sites.filter((s) => s.latitude !== null && s.longitude !== null);66  const objectTypes = [...d.object_type_distribution].sort((a, b) => (num(b.on_orbit) ?? 0) - (num(a.on_orbit) ?? 0));6768  const jsonLd = {69    '@context': 'https://schema.org',70    '@type': 'Country',71    name: d.name,72    identifier: d.iso3 ?? d.code,73    url: `${SITE_URL}${routes.country(d.slug)}`,74  };7576  return (77    <Container wide>78      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />7980      <header className="pb-6 pt-8 md:pb-8 md:pt-12">81        <nav aria-label="Breadcrumb" className="eyebrow">82          <Link href={routes.countries()} className="hover:text-ink">Countries</Link> <span aria-hidden>/</span> {d.name}83        </nav>84        <div className="mt-3 flex flex-wrap items-center gap-2">85          <Tag tone="accent">{d.code}</Tag>86          {d.iso3 && <Tag>{d.iso3}</Tag>}87          {d.region && <Tag>{d.region}</Tag>}88        </div>89        <h1 className="display mt-3 text-3xl md:text-5xl">{d.name}</h1>90        <p className="mt-3 max-w-2xl text-[15px] text-ink-2 md:text-base">91          {fmtInt(d.active_payloads)} active payloads · {fmtInt(d.objects_on_orbit)} objects on orbit · {fmtInt(d.operators)} operators92        </p>93        <HeroFacts94          items={[95            { label: 'Rank · active payloads', value: <span className="tnum font-medium"><Rank n={d.rank_active} of={nCountries} /></span> },96            { label: 'Rank · objects on orbit', value: <span className="tnum font-medium"><Rank n={d.rank_objects} of={nCountries} /></span> },97            { label: 'Rank · debris on orbit', value: <span className="tnum font-medium"><Rank n={d.rank_debris} of={nCountries} /></span> },98          ]}99        />100        {nCountries === null && <p className="mt-2 text-xs text-ink-3">Country total unavailable — ranks shown without denominator.</p>}101      </header>102103      <KpiStrip104        items={[105          { label: 'Active payloads', value: <span className="text-active">{fmtInt(d.active_payloads)}</span> },106          { label: 'Payloads on orbit', value: fmtInt(d.on_orbit_payloads) },107          { label: 'Total payloads', value: fmtInt(d.total_payloads) },108          { label: 'Objects on orbit', value: fmtInt(d.objects_on_orbit) },109          { label: 'Debris on orbit', value: <span className="text-warn">{fmtInt(d.debris_on_orbit)}</span> },110          { label: 'Rocket bodies', value: fmtInt(d.rocket_bodies_on_orbit) },111          { label: 'Total objects', value: fmtInt(d.total_objects) },112          { label: 'Launches', value: fmtInt(d.launches) },113          { label: 'Operators', value: fmtInt(d.operators) },114          { label: 'Payloads 365 d', value: fmtInt(d.payloads_last_365d) },115          { label: 'Snapshot', value: <span className="text-base text-ink-2 md:text-lg">{fmtDateTime(generatedAt)}</span> },116        ]}117      />118119      <div className="grid gap-x-10 lg:grid-cols-[minmax(0,7fr)_minmax(0,4fr)]">120        <div className="min-w-0">121          <Block eyebrow="Growth" title="Growth by year" id="growth">122            {d.growth.length ? (123              <div className="grid gap-6 lg:grid-cols-2">124                <div>125                  <p className="eyebrow mb-2">Payloads launched · still active vs no longer active</p>126                  <StackedBars data={payloadGrowth} keys={['still_active', 'retired']} labels={{ still_active: 'Still active', retired: 'No longer active' }} title="Payloads by launch year" height={190} />127                </div>128                <div>129                  <p className="eyebrow mb-2">Launches per year</p>130                  <Bars data={launchGrowth} title="Launches per year" height={190} color="var(--series-4)" highlightLast />131                </div>132              </div>133            ) : (134              <Unavailable what="Growth history" />135            )}136          </Block>137          <Block eyebrow="Organisations" title={`Operators · ${fmtInt(d.operators)}`} id="operators" action={{ href: `${routes.operators()}?country=${encodeURIComponent(d.slug)}`, label: 'All operators' }}>138            <OperatorsMiniTable rows={d.operators_list} />139          </Block>140          <Block eyebrow="Programmes" title="Constellations" id="constellations">141            <ConstellationsMiniTable rows={d.constellations_list} />142          </Block>143          <Block eyebrow="Ground" title="Launch sites" id="launch-sites" action={{ href: routes.launchSites(), label: 'All launch sites' }}>144            {d.launch_sites.length === 0 ? (145              <p className="text-sm text-ink-3">No launch site on this country's territory in the catalogue; its payloads fly from foreign sites (see recent launches).</p>146            ) : (147              <div className="space-y-4">148                {sites.length > 0 && <WorldMap title={`Launch sites in ${d.name}`} markers={sites.map((s) => ({ lat: s.latitude!, lon: s.longitude!, label: s.code, href: routes.launchSite(s.slug), size: 4 + Math.min(6, Math.log10((num(s.launches) ?? 1) + 1) * 2) }))} />}149                <ScrollTable>150                  <table className="data-table stack">151                    <thead>152                      <tr>153                        <th>Site</th>154                        <th>Code</th>155                        <th className="num">Launches</th>156                        <th>Last launch</th>157                      </tr>158                    </thead>159                    <tbody>160                      {d.launch_sites.map((s) => (161                        <tr key={s.code}>162                          <td className="primary" data-label="Site"><Link href={routes.launchSite(s.slug)} className="link">{s.name}</Link></td>163                          <td data-label="Code" className="mono text-xs text-ink-2">{s.code}</td>164                          <td data-label="Launches" className="num tnum">{fmtInt(s.launches)}</td>165                          <td data-label="Last launch" className="tnum text-ink-2">{fmtDate(s.last_launch)}</td>166                        </tr>167                      ))}168                    </tbody>169                  </table>170                </ScrollTable>171              </div>172            )}173          </Block>174          <Block eyebrow="Fleet" title="Recent satellites" id="satellites" action={{ href: routes.satellites(`country=${encodeURIComponent(d.slug)}`), label: 'All satellites' }}>175            <SatellitesTable rows={d.recent_satellites} columns={['type', 'orbit']} />176          </Block>177          <Block eyebrow="Launches" title="Recent launches" id="launches" action={{ href: routes.launches(`country=${encodeURIComponent(d.slug)}`), label: 'All launches' }}>178            <LaunchesTable rows={d.recent_launches} showPrimary />179          </Block>180          <Block eyebrow="Timeline" title="Events" id="events" action={{ href: routes.events(), label: 'All events' }}>181            <EventsList events={d.events} />182          </Block>183        </div>184185        <aside className="min-w-0 lg:border-l lg:border-rule lg:pl-10">186          <Block eyebrow="Payloads on orbit" title={<span className="inline-flex items-center gap-2">Orbital distribution <Link href={routes.methodology()} className="text-[10px] font-semibold uppercase tracking-[0.12em] text-accent-2 hover:underline">derived</Link></span>}>187            <Donut data={orbitData} title="Payloads on orbit by orbit class" size={140} />188          </Block>189          <Block eyebrow="Payloads" title={<span className="inline-flex items-center gap-2">Mission distribution <Link href={routes.methodology()} className="text-[10px] font-semibold uppercase tracking-[0.12em] text-accent-2 hover:underline">derived</Link></span>}>190            <HBars data={missionData} />191          </Block>192          <Block eyebrow="Catalogue" title="Object types">193            {objectTypes.length === 0 ? (194              <Unavailable what="Object type breakdown" compact />195            ) : (196              <ScrollTable>197                <table className="data-table">198                  <thead>199                    <tr>200                      <th>Type</th>201                      <th className="num">On orbit</th>202                      <th className="num">Total</th>203                    </tr>204                  </thead>205                  <tbody>206                    {objectTypes.map((o) => (207                      <tr key={o.object_type}>208                        <td>{OBJECT_TYPE_LABELS[o.object_type] ?? titleCase(o.object_type)}</td>209                        <td className="num tnum font-medium">{fmtInt(o.on_orbit)}</td>210                        <td className="num tnum text-ink-2">{fmtInt(o.total)}</td>211                      </tr>212                    ))}213                  </tbody>214                </table>215              </ScrollTable>216            )}217          </Block>218          <Block eyebrow="Attribution" title="Owner codes">219            {d.owner_codes.length === 0 ? (220              <Unavailable what="Owner code mapping" compact />221            ) : (222              <ul className="divide-y divide-rule text-sm">223                {d.owner_codes.map((o) => (224                  <li key={o.code} className="flex items-center justify-between gap-3 py-2">225                    <span className="min-w-0 truncate text-ink-2">{o.name}</span>226                    <span className="flex shrink-0 items-center gap-2">227                      <Tag>{o.kind}</Tag>228                      <code className="mono rounded bg-plane-2 px-1.5 py-0.5 text-xs text-ink">{o.code}</code>229                    </span>230                  </li>231                ))}232              </ul>233            )}234            <p className="mt-3 text-xs text-ink-3">235              SATCAT owner codes mapped to this country. Joint programmes are attributed to the lead country; the mapping is versioned in the <Link href={routes.methodology()} className="text-accent hover:underline">methodology</Link>.236            </p>237          </Block>238          <Block eyebrow="Regulatory" title="Registrations & licenses">239            <PlannedUnavailable what="Registrations & licenses" note="UNOOSA register and national regulatory connectors are planned; entries will carry their source once ingested." />240          </Block>241        </aside>242      </div>243    </Container>244  );245}246